import child_process from "child_process";
import { existsSync, readFileSync, copyFileSync, statSync, unlinkSync } from "fs";
import { readFile, writeFile } from "fs/promises";
import { EOL } from "os";
import path from "path";
import { createRequire } from "module";
import { finished } from "stream/promises";
import fs from 'fs'

import esbuild from "esbuild";
import { globby } from "globby";
import glslStripComments from "glsl-strip-comments";
import gulp from "gulp";
import { rimraf } from "rimraf";

import { mkdirp } from "mkdirp";

import JavaScriptObfuscator from 'javascript-obfuscator';
import { dtsPlugin } from "esbuild-plugin-d.ts";
import cssModulesPlugin from "esbuild-css-modules-plugin";

const inlineCssPlugin = {
  name: 'inline-css',
  setup(build) {
    build.onLoad({ filter: /\.css$/ }, async (args) => {
      const cssContent = await fs.promises.readFile(args.path, 'utf8');

      // 更全面的转义处理
      const escaped = cssContent
        .replace(/\\/g, '\\\\')  // 先转义反斜杠
        .replace(/"/g, '\\"')    // 转义双引号
        .replace(/'/g, "\\'")    // 转义单引号
        .replace(/\n/g, '\\n')  // 转义换行符
        .replace(/\r/g, '\\r');  // 转义回车符

      return {
        contents: `
          (function() {
            var style = document.createElement('style');
            style.textContent = "${escaped}";
            document.head.appendChild(style);
          })();
        `,
        loader: 'js',
      };
    });
  },
};

// Determines the scope of the workspace packages. If the scope is set to cesium, the workspaces should be @cesium/engine.
// This should match the scope of the dependencies of the root level package.json.
const scope = "weatherearth";

const require = createRequire(import.meta.url);
const packageJson = require("../package.json");
let version = packageJson.version;
if (/\.0$/.test(version)) {
  version = version.substring(0, version.length - 2);
}

const copyrightHeaderTemplate = readFileSync(
  path.join("Source", "copyrightHeader.js"),
  "utf8",
);
const combinedCopyrightHeader = copyrightHeaderTemplate.replace(
  "${version}",
  version,
);

function escapeCharacters(token) {
  return token.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
}

function constructRegex(pragma, exclusive) {
  const prefix = exclusive ? "exclude" : "include";
  pragma = escapeCharacters(pragma);

  const s =
    `[\\t ]*\\/\\/>>\\s?${prefix}Start\\s?\\(\\s?(["'])${pragma}\\1\\s?,\\s?pragmas\\.${pragma}\\s?\\)\\s?;?` +
    // multiline code block
    `[\\s\\S]*?` +
    // end comment
    `[\\t ]*\\/\\/>>\\s?${prefix}End\\s?\\(\\s?(["'])${pragma}\\2\\s?\\)\\s?;?\\s?[\\t ]*\\n?`;

  return new RegExp(s, "gm");
}

const pragmas = {
  debug: false,
};
const stripPragmaPlugin = {
  name: "strip-pragmas",
  setup: (build) => {
    build.onLoad({ filter: /\.js$/ }, async (args) => {
      let source = await readFile(args.path, { encoding: "utf8" });

      try {
        for (const key in pragmas) {
          if (pragmas.hasOwnProperty(key)) {
            source = source.replace(constructRegex(key, pragmas[key]), "");
          }
        }

        return { contents: source };
      } catch (e) {
        return {
          errors: {
            text: e.message,
          },
        };
      }
    });
  },
};

// Print an esbuild warning
function printBuildWarning({ location, text }) {
  const { column, file, line, lineText, suggestion } = location;

  let message = `\n
  > ${file}:${line}:${column}: warning: ${text}
  ${lineText}
  `;

  if (suggestion && suggestion !== "") {
    message += `\n${suggestion}`;
  }

  console.log(message);
}

// Ignore `eval` warnings in third-party code we don't have control over
function handleBuildWarnings(result) {
  for (const warning of result.warnings) {
    if (!warning.location.file.includes("protobufjs.js")) {
      printBuildWarning(warning);
    }
  }
}

export const defaultESBuildOptions = () => {
  return {
    bundle: true,
    color: true,
    legalComments: `inline`,
    logLimit: 0,
    target: `es2020`,
  };
};

export async function getFilesFromWorkspaceGlobs(workspaceGlobs) {
  let files = [];
  // Iterate over each workspace and generate declarations for each file.
  for (const workspace of Object.keys(workspaceGlobs)) {
    // Since workspace source files are provided relative to the workspace,
    // the workspace path needs to be prepended.
    const workspacePath = `packages/${workspace.replace(`${scope}/`, ``)}`;
    const filesPaths = workspaceGlobs[workspace].map((glob) => {
      if (glob.indexOf(`!`) === 0) {
        return `!`.concat(workspacePath, `/`, glob.replace(`!`, ``));
      }
      return workspacePath.concat("/", glob);
    });

    files = files.concat(await globby(filesPaths));
  }
  return files;
}

const inlineWorkerPath = "Build/InlineWorkers.js";

/**
 * @typedef {object} CesiumBundles
 * @property {object} esm The ESM bundle.
 * @property {object} iife The IIFE bundle, for use in browsers.
 * @property {object} node The CommonJS bundle, for use in NodeJS.
 */

/**
 * Bundles all individual modules, optionally minifying and stripping out debug pragmas.
 * @param {object} options
 * @param {string} options.path Directory where build artifacts are output
 * @param {boolean} [options.minify=false] true if the output should be minified
 * @param {boolean} [options.removePragmas=false] true if the output should have debug pragmas stripped out
 * @param {boolean} [options.sourcemap=false] true if an external sourcemap should be generated
 * @param {boolean} [options.iife=false] true if an IIFE style module should be built
 * @param {boolean} [options.node=false] true if a CJS style node module should be built
 * @param {boolean} [options.incremental=false] true if build output should be cached for repeated builds
 * @param {boolean} [options.write=true] true if build output should be written to disk. If false, the files that would have been written as in-memory buffers
 * @returns {Promise<CesiumBundles>}
 */
export async function bundleCesiumJs(options) {
  const buildConfig = defaultESBuildOptions();
  buildConfig.entryPoints = ["./Source/WeatherEarth.js"];
  buildConfig.minify = options.minify;
  buildConfig.sourcemap = options.sourcemap;
  buildConfig.write = options.write;

  const plugins = [
    inlineCssPlugin
  ]

  if (options.removePragmas) {
    plugins.push(stripPragmaPlugin)
  }

  plugins.push(dtsPlugin({
    tsconfig: options.minify ? 'tsconfig.minify.json' : 'tsconfig.json'
  }))

  // print errors immediately, and collect warnings so we can filter out known ones
  buildConfig.logLevel = "info";
  buildConfig.external = ['cesium', 'pbf',
    'pako', 'ol',
    'ol/format/MVT',
    'ol/format/GeoJSON',
    'ol/style/Style',
    'ol/style/Stroke',
    'ol/style/Fill',
    'ol/render',
    'ol/proj', 'libgif',
    'bitmap-sdf',
    'grapheme-splitter',
    'libgif']
  buildConfig.plugins = plugins

  const contexts = {};
  const incremental = options.incremental;
  let build = esbuild.build;
  if (incremental) {
    build = esbuild.context;
  }

  // Build ESM
  const esm = await build({
    ...buildConfig,
    minify: false,
    format: "esm",
    outfile: path.join(options.path, "WeatherEarth.js"),
  });

  if (options.minify) {
    const outputFile = path.join(options.path, "WeatherEarth.js");
    const code = fs.readFileSync(path.join(options.path, "WeatherEarth.js"), 'utf8');
    const result = await JavaScriptObfuscator.obfuscate(code, {
      // compact: true,
      // controlFlowFlattening: true,
      // identifierNamesGenerator: 'hexadecimal',
      // renameGlobals: true,
      // selfDefending: true,
      // stringArray: true,
      // stringArrayEncoding: ['rc4'],
      target: 'browser',
    });
    fs.writeFileSync(outputFile, result.getObfuscatedCode());
  }

  if (incremental) {
    contexts.esm = esm;
  } else {
    handleBuildWarnings(esm);
  }

  return contexts;
}

function filePathToModuleId(moduleId) {
  return moduleId.substring(0, moduleId.lastIndexOf(".")).replace(/\\/g, "/");
}

const workspaceSourceFiles = [
  "Source/**/*.ts",
  "!Source/*.ts",
  "Source/**/*.js",
  "!Source/*.js",
  "!Source/ThirdParty/**",
  "!Source/Workers/**",
  '!Source/Widgets/Navigation/**/*',
]

const workspaceSpecFiles = {
  engine: ["Specs/**/*Spec.js"],
  widgets: ["packages/widgets/Specs/**/*Spec.js"],
};

/**
 * Creates a single entry point file, Specs/SpecList.js, which imports all individual spec files.
 * @returns {Buffer} contents
 */
export async function createCombinedSpecList() {
  let contents = `export const VERSION = '${version}';\n`;

  for (const workspace of Object.keys(workspaceSpecFiles)) {
    const files = await globby(workspaceSpecFiles[workspace]);
    for (const file of files) {
      contents += `import '../${file}';\n`;
    }
  }

  await writeFile(path.join("Specs", "SpecList.js"), contents, {
    encoding: "utf-8",
  });

  return contents;
}

/**
 * @param {object} options
 * @param {string} options.path output directory
 * @param {boolean} [options.iife=false] true if the worker output should be inlined into a top-level iife file, ie. in Cesium.js
 * @param {boolean} [options.minify=false] true if the worker output should be minified
 * @param {boolean} [options.removePragmas=false] true if debug pragma should be removed
 * @param {boolean} [options.sourcemap=false] true if an external sourcemap should be generated
 * @param {boolean} [options.incremental=false] true if build output should be cached for repeated builds
 * @param {boolean} [options.write=true] true if build output should be written to disk. If false, the files that would have been written as in-memory buffers
 */
export async function bundleWorkers(options) {
  // Bundle Cesium workers
  const workers = await globby(["Source/Workers/**"]);
  const workerConfig = defaultESBuildOptions();
  workerConfig.bundle = true;
  workerConfig.external = ["fs", "path"];

  if (options.iife) {
    let contents = ``;
    const files = await globby(workers);
    const declarations = files.map((file) => {
      let assignmentName = path.basename(file, path.extname(file));
      assignmentName = assignmentName.replace(/(\.|-)/g, "_");
      return `export const ${assignmentName} = () => { import('./${file}'); };`;
    });
    contents += declarations.join(`${EOL}`);
    contents += "\n";

    workerConfig.globalName = "CesiumWorkers";
    workerConfig.format = "iife";
    workerConfig.stdin = {
      contents: contents,
      resolveDir: ".",
    };
    workerConfig.minify = options.minify;
    workerConfig.write = false;
    workerConfig.logOverride = {
      "empty-import-meta": "silent",
    };
  } else {
    workerConfig.format = "esm";
    workerConfig.splitting = true;
    workerConfig.entryPoints = workers;
    workerConfig.outdir = path.join(options.path, "Workers");
    workerConfig.minify = Boolean(options.minify);
    workerConfig.write = options.write;
  }

  const incremental = options.incremental;
  let build = esbuild.build;
  if (incremental) {
    build = esbuild.context;
  }

  if (!options.iife) {
    return build(workerConfig);
  }

  //if iife, write this output to it's own file in which the script content is exported
  const writeInjectionCode = (result) => {
    const bundle = result.outputFiles[0].contents;
    const base64 = Buffer.from(bundle).toString("base64");
    const contents = `globalThis.CESIUM_WORKERS = atob("${base64}");`;
    return writeFile(inlineWorkerPath, contents);
  };

  if (incremental) {
    const context = await build(workerConfig);
    const rebuild = context.rebuild;
    context.rebuild = async () => {
      const result = await rebuild();
      if (result) {
        await writeInjectionCode(result);
      }
      return result;
    };
    return context;
  }

  const result = await build(workerConfig);
  return writeInjectionCode(result);
}

const shaderFiles = [
  "Source/Shaders/**/*.glsl",
];
export async function glslToJavaScript(minify, minifyStateFilePath) {
  await writeFile(minifyStateFilePath, minify.toString());
  const minifyStateFileLastModified = existsSync(minifyStateFilePath)
    ? statSync(minifyStateFilePath).mtime.getTime()
    : 0;

  // collect all currently existing JS files into a set, later we will remove the ones
  // we still are using from the set, then delete any files remaining in the set.
  const leftOverJsFiles = {};

  const files = await globby([
    `Source/Shaders/**/*.ts`,
  ]);
  files.forEach(function (file) {
    leftOverJsFiles[path.normalize(file)] = true;
  });

  const glslFiles = await globby(shaderFiles);

  await Promise.all(
    glslFiles.map(async function (glslFile) {
      glslFile = path.normalize(glslFile);
      const baseName = path.basename(glslFile, ".glsl");
      const jsFile = `${path.join(path.dirname(glslFile), baseName)}.ts`;

      delete leftOverJsFiles[jsFile];

      const jsFileExists = existsSync(jsFile);
      const jsFileModified = jsFileExists
        ? statSync(jsFile).mtime.getTime()
        : 0;
      const glslFileModified = statSync(glslFile).mtime.getTime();

      if (
        jsFileExists &&
        jsFileModified > glslFileModified &&
        jsFileModified > minifyStateFileLastModified
      ) {
        return;
      }

      let contents = await readFile(glslFile, { encoding: "utf8" });
      contents = contents.replace(/\r\n/gm, "\n");

      let copyrightComments = "";
      const extractedCopyrightComments = contents.match(
        /\/\*\*(?:[^*\/]|\*(?!\/)|\n)*?@license(?:.|\n)*?\*\//gm,
      );
      if (extractedCopyrightComments) {
        copyrightComments = `${extractedCopyrightComments.join("\n")}\n`;
      }

      if (minify) {
        contents = glslStripComments(contents);
        contents = contents
          .replace(/\s+$/gm, "")
          .replace(/^\s+/gm, "")
          .replace(/\n+/gm, "\n");
        contents += "\n";
      }

      contents = contents.split('"').join('\\"').replace(/\n/gm, "\\n\\\n");
      contents = `${copyrightComments}\
//This file is automatically rebuilt by the Cesium build process.\n\
export default "${contents}" as string;\n`;

      return writeFile(jsFile, contents);
    }),
  );

  // delete any left over JS files from old shaders
  Object.keys(leftOverJsFiles).forEach(function (filepath) {
    rimraf.sync(filepath);
  });
}

const externalResolvePlugin = {
  name: "external-cesium",
  setup: (build) => {
    // In Specs, when we import files from the source files, we import
    // them from the index.js files. This plugin replaces those imports
    // with the IIFE Cesium.js bundle that's loaded in the browser
    // in SpecRunner.html.
    build.onResolve({ filter: new RegExp(`index\.js$`) }, () => {
      return {
        path: "Cesium",
        namespace: "external-cesium",
      };
    });

    build.onResolve({ filter: /@cesium/ }, () => {
      return {
        path: "Cesium",
        namespace: "external-cesium",
      };
    });

    build.onLoad(
      {
        filter: new RegExp(`^Cesium$`),
        namespace: "external-cesium",
      },
      () => {
        const contents = `module.exports = Cesium`;
        return {
          contents,
        };
      },
    );
  },
};

/**
 * Helper function to copy files.
 *
 * @param {string[]} globs The file globs to be copied.
 * @param {string} destination The path to copy the files to.
 * @param {string} base The base path to omit from the globs when files are copied. Defaults to "".
 * @returns {Promise<Buffer>} A promise containing the stream output as a buffer.
 */
export async function copyFiles(globs, destination, base) {
  const stream = gulp
    .src(globs, { nodir: true, base: base ?? "", encoding: false })
    .pipe(gulp.dest(destination));

  await finished(stream);
  return stream;
}

/**
 * Creates the index.js for a package.
 *
 * @param {string} workspace The workspace to create the index.js for.
 * @returns
 */
export async function createIndexJs() {
  let contents = `globalThis.VERSION = "${version}";\n`;

  const files = await globby(workspaceSourceFiles);
  files.forEach(function (file) {
    let moduleId = file;
    moduleId = filePathToModuleId(moduleId);

    // Rename shader files, such that ViewportQuadFS.glsl is exported as _shadersViewportQuadFS in JS.

    let assignmentName = path.basename(file, path.extname(file));
    if (moduleId.indexOf(`Source/Shaders/`) === 0) {
      assignmentName = `_shaders${assignmentName}`;
    }
    assignmentName = assignmentName.replace(/(\.|-)/g, "_");

    // lgw
    // Source/
    moduleId = moduleId.substring(moduleId.indexOf('/') + 1, moduleId.length)

    contents += `export { default as ${assignmentName} } from './${moduleId}';${EOL}`;
  });

  await writeFile(`./Source/WeatherEarth.js`, contents, {
    encoding: "utf-8",
  });

  return contents;
}

export async function createIndexDTs(outputDirectory) {

  let contents = `export declare const VERSION = "${version}";\n`;

  const files = await globby(workspaceSourceFiles);
  files.forEach(function (file) {
    let moduleId = file;
    moduleId = filePathToModuleId(moduleId);

    // Rename shader files, such that ViewportQuadFS.glsl is exported as _shadersViewportQuadFS in JS.

    let assignmentName = path.basename(file, path.extname(file));
    if (moduleId.indexOf(`Source/Shaders/`) === 0) {
      assignmentName = `_shaders${assignmentName}`;
    }
    assignmentName = assignmentName.replace(/(\.|-)/g, "_");

    // lgw
    // Source/
    moduleId = moduleId.substring(moduleId.indexOf('/') + 1, moduleId.length)

    contents += `export { default as ${assignmentName} } from './${moduleId}';${EOL}`;
  });

  await writeFile(path.join(outputDirectory, 'WeatherEarth.d.ts'), contents, {
    encoding: "utf-8",
  });

  return contents;
}

/**
 * Builds the engine workspace.
 *
 * @param {object} options
 * @param {boolean} [options.incremental=false] True if builds should be generated incrementally.
 * @param {boolean} [options.minify=false] True if bundles should be minified.
 * @param {boolean} [options.write=true] True if bundles generated are written to files instead of in-memory buffers.
 */
export const buildEngine = async (options) => {
  options = options || {};

  const incremental = options.incremental ?? false;
  const minify = options.minify ?? false;
  const write = options.write ?? true;

  // Create Build folder to place build artifacts.
  mkdirp.sync("Build");

  // Convert GLSL files to JavaScript modules.
  await glslToJavaScript(
    minify,
    "Build/minifyShaders.state",
  );

  // Create index.js
  await createIndexJs();

  const outputDirectory =
    options.outputDirectory ??
    path.join("Build", `WeatherEarth${!minify ? "Unminified" : ""}`);
  rimraf.sync(outputDirectory);

  // Build workers.
  await bundleWorkers({
    ...options,
    iife: false,
    path: outputDirectory,
  });
};

/**
 * Build CesiumJS.
 *
 * @param {object} options
 * @param {boolean} [options.development=true] True if build is targeted for development.
 * @param {boolean} [options.iife=true] True if IIFE bundle should be generated.
 * @param {boolean} [options.incremental=true] True if builds should be generated incrementally.
 * @param {boolean} [options.minify=false] True if bundles should be minified.
 * @param {boolean} [options.node=true] True if CommonJS bundle should be generated.
 * @param {boolean} options.outputDirectory The directory where the output should go.
 * @param {boolean} [options.removePragmas=false] True if debug pragmas should be removed.
 * @param {boolean} [options.sourcemap=true] True if sourcemap should be included in the generated bundles.
 * @param {boolean} [options.write=true] True if bundles generated are written to files instead of in-memory buffers.
 */
export async function buildWeatherEarth(options) {
  const development = options.development ?? true;
  const iife = options.iife ?? true;
  const incremental = options.incremental ?? false;
  const minify = options.minify ?? false;
  const node = options.node ?? true;
  const removePragmas = options.removePragmas ?? false;
  const sourcemap = options.sourcemap ?? true;
  const write = options.write ?? true;

  // Generate Build folder to place build artifacts.
  mkdirp.sync("Build");
  const outputDirectory =
    options.outputDirectory ??
    path.join("Build", `WeatherEarth${!minify ? "Unminified" : ""}`);

  await writeFile(
    "Build/package.json",
    JSON.stringify({
      type: "commonjs",
    }),
    "utf8",
  );

  // Generate bundles.
  const contexts = await bundleCesiumJs({
    minify: minify,
    iife: iife,
    incremental: incremental,
    sourcemap: sourcemap,
    removePragmas: removePragmas,
    path: outputDirectory,
    node: node,
    write: write,
  });


  await createIndexDTs(outputDirectory);
}
